Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit cf25ed8a64c05eb628cfe5029c63358ebfa371e8


Parents : f8c57e6
Author : Jeremy O'Brien <neutral@fastmail.com>
Date : 2026-05-21T16:16:06-04:00

Browser/Conversations: add ability to copy browser URL and conversation message text

Changes
Diff

diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py
index eb4d2ae..15abd7b 100644
--- a/nomadnet/ui/TextUI.py
+++ b/nomadnet/ui/TextUI.py
@@ -168,6 +168,7 @@ GLYPHS = {
("image", "[I]", "\u25a3", "\uf1c5"),
("audio", "[~]", "\u266b", "\uf1c7"),
("pin", "*", "\u2605", "\uf08d"),
+ ("copy", "[C]", "\u29c9", "\uf0c5"),
}
class TextUI:

diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py
index a08d8e6..46e11f2 100644
--- a/nomadnet/ui/textui/Browser.py
+++ b/nomadnet/ui/textui/Browser.py
@@ -14,6 +14,7 @@ from nomadnet.Directory import DirectoryEntry
from nomadnet.vendor.Scrollable import *
from nomadnet.util import strip_modifiers
from nomadnet.util import sanitize_name
+from .Helpers import ClickableIcon, osc52_copy
class BrowserFrame(urwid.Frame):
def keypress(self, size, key):
@@ -33,6 +34,8 @@ class BrowserFrame(urwid.Frame):
self.delegate.save_node_dialog()
elif key == "ctrl g":
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen()
+ elif key == "ctrl y":
+ self.delegate.copy_url()
elif self.focus_position == "body":
if key == "down" or key == "up":
try:
@@ -488,7 +491,15 @@ class Browser:
return urwid.AttrMap(widget, "browser_controls")
def make_control_widget(self):
- return urwid.AttrMap(urwid.Pile([urwid.Text(self.g["node"]+" "+self.current_url()), urwid.Divider(self.g["divider1"])]), "browser_controls")
+ url_text = urwid.Text(self.g["node"]+" "+self.current_url())
+ copy_glyph = self.g.get("copy", "[C]")
+ copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url())
+ copy_width = len(copy_glyph) + 2
+ header_row = urwid.Columns([
+ ("weight", 1, url_text),
+ (copy_width, urwid.Padding(copy_icon, left=1, right=1)),
+ ])
+ return urwid.AttrMap(urwid.Pile([header_row, urwid.Divider(self.g["divider1"])]), "browser_controls")
def make_request_failed_widget(self):
def back_action(sender):
@@ -1067,6 +1078,34 @@ class Browser:
self.uncache_page(self.current_url())
self.load_page()
+ def copy_url(self):
+ url = self.current_url()
+ if not url:
+ return
+
+ if not osc52_copy(url):
+ return
+
+ try:
+ notice = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Copied URL to clipboard")]), "browser_controls")
+ if self.page_background_color != None or self.page_foreground_color != None:
+ style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color))
+ notice.set_attr_map({None: style_name})
+ self.browser_footer = notice
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+
+ def restore_footer(loop, user_data):
+ if self.status == Browser.DONE:
+ self.browser_footer = self.make_status_widget()
+ else:
+ self.browser_footer = urwid.Text("")
+ if self.frame is not None:
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+
+ self.app.ui.loop.set_alarm_in(2.0, restore_footer)
+ except Exception as e:
+ RNS.log("Could not update footer after URL copy: "+str(e), RNS.LOG_ERROR)
+
def close_dialogs(self):
options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width)
self.delegate.columns.contents[1] = (self.display_widget, options)

diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py
index f5f5b2b..291c7f0 100644
--- a/nomadnet/ui/textui/Conversations.py
+++ b/nomadnet/ui/textui/Conversations.py
@@ -20,6 +20,7 @@ from nomadnet.util import sanitize_name
from RNS.Utilities.rngit.util import MarkdownToMicron
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from .MicronParser import markup_to_attrmaps
+from .Helpers import ClickableIcon, osc52_copy
from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
from nomadnet.ui import THEME_DARK, THEME_LIGHT
@@ -2235,7 +2236,7 @@ class ConversationWidget(urwid.WidgetWrap):
added_hashes.append(message_hash)
was_loaded = message.loaded
try:
- message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"])
+ message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self)
except Exception as e:
RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING)
message.unload()
@@ -2550,9 +2551,10 @@ class ConversationWidget(urwid.WidgetWrap):
class LXMessageWidget(urwid.WidgetWrap):
mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
- def __init__(self, message, theme=THEME_DARK):
+ def __init__(self, message, theme=THEME_DARK, conversation_widget=None):
app = nomadnet.NomadNetworkApp.get_shared_instance()
g = app.ui.glyphs
+ self._conversation_widget = conversation_widget
self.timestamp = message.get_timestamp()
self.sort_timestamp = message.sort_timestamp
self.transfer_done = False
@@ -2629,12 +2631,47 @@ class LXMessageWidget(urwid.WidgetWrap):
attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname)
title_string += " | " + " ".join(attachment_strings)
- title = urwid.AttrMap(urwid.Text(title_string), header_style)
+ content_text = message.get_content()
+
+ if content_text:
+ copy_glyph = g.get("copy", "[C]")
+ check_glyph = g.get("check", "v").center(len(copy_glyph))
+ copy_icon = ClickableIcon(copy_glyph)
+
+ conv_widget = self._conversation_widget
+ def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget):
+ osc52_copy(content)
+ icon.set_text(kg)
+ def _restore(loop, user_data):
+ icon.set_text(cg)
+ try:
+ app.ui.loop.set_alarm_in(2.0, _restore)
+ except Exception:
+ icon.set_text(cg)
+ if cw is not None and cw.frame is not None:
+ def _refocus(loop, user_data):
+ try:
+ cw.frame.focus_position = "footer"
+ except Exception:
+ pass
+ try:
+ app.ui.loop.set_alarm_in(0, _refocus)
+ except Exception:
+ pass
+ copy_icon._on_click = on_copy_click
+
+ copy_width = len(copy_glyph) + 2
+ title_row = urwid.Columns([
+ ("weight", 1, urwid.Text(title_string)),
+ (copy_width, urwid.Padding(copy_icon, left=1, right=1)),
+ ])
+ title = urwid.AttrMap(title_row, header_style)
+ else:
+ title = urwid.AttrMap(urwid.Text(title_string), header_style)
self.progress_widget = urwid.Text("")
self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full")
- content_text = message.get_content()
content_lines = content_text.split("\n")
markdown = renderer == LXMF.RENDERER_MARKDOWN

diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py
index e69de29..ec0816e 100644
--- a/nomadnet/ui/textui/Helpers.py
+++ b/nomadnet/ui/textui/Helpers.py
@@ -0,0 +1,31 @@
+import sys
+import base64
+import urwid
+import RNS
+
+
+def osc52_copy(text):
+ if not text:
+ return False
+ try:
+ encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
+ sys.stdout.write("\x1b]52;c;" + encoded + "\x07")
+ sys.stdout.flush()
+ return True
+ except Exception as e:
+ RNS.log("Could not emit clipboard escape sequence: "+str(e), RNS.LOG_ERROR)
+ return False
+
+
+class ClickableIcon(urwid.Text):
+ _selectable = False
+
+ def __init__(self, text, on_click=None):
+ super().__init__(text)
+ self._on_click = on_click
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and "press" in event and self._on_click is not None:
+ self._on_click()
+ return True
+ return False

diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py
index c2e3150..17130e2 100644
--- a/nomadnet/ui/textui/Network.py
+++ b/nomadnet/ui/textui/Network.py
@@ -16,7 +16,7 @@ class NetworkDisplayShortcuts():
self.app = app
g = app.ui.glyphs
- self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar")
+ self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-y] Copy [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar")
class DialogLineBox(urwid.LineBox):
def keypress(self, size, key):


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────